security: fix organization takeover exploit chain#280
Open
TerminalsandCoffee wants to merge 1 commit intoabhi1693:masterfrom
Open
security: fix organization takeover exploit chain#280TerminalsandCoffee wants to merge 1 commit intoabhi1693:masterfrom
TerminalsandCoffee wants to merge 1 commit intoabhi1693:masterfrom
Conversation
…+ email bypass + role escalation) Three chained vulnerabilities allow any authenticated admin to fully take over any organization: 1. Invite token leaked in API responses — OrganizationInviteRead included the raw token field. Replaced with has_token boolean using a wrap model_validator to compute from the ORM field without exposure. 2. Email validation bypass — the accept_org_invite endpoint short-circuited the email check when either invited_email or user email was None/empty. Now rejects acceptance when either email is missing. 3. No role hierarchy enforcement — admins could create invites with role=owner or promote members above their own level. Added ROLE_RANK comparison to both create_org_invite and update_org_member that blocks granting roles at or above the caller's rank. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
abhi1693
requested changes
Mar 16, 2026
| updated_at: datetime | ||
|
|
||
| @model_validator(mode="wrap") | ||
| @classmethod |
Owner
There was a problem hiding this comment.
Please remove unrelated changes
Contributor
There was a problem hiding this comment.
Pull request overview
Addresses a critical organization-takeover exploit chain in the backend org invite/membership flows by preventing invite-token exposure, tightening invite acceptance validation, and adding role hierarchy enforcement.
Changes:
- Replace
OrganizationInviteRead.tokenwithhas_tokencomputed at validation time to avoid leaking raw invite tokens. - Enforce strict email-binding in
accept_org_inviteby rejecting invites when either the invite email or the authenticated user email is missing, and rejecting mismatches. - Add role hierarchy checks to block privilege escalation during org invite creation and member role updates.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| backend/app/schemas/organizations.py | Removes raw invite token from read payloads and adds computed has_token flag. |
| backend/app/api/organizations.py | Adds role hierarchy enforcement for invites/membership updates and hardens invite acceptance email validation. |
Comment on lines
+121
to
123
| has_token: bool = False | ||
| created_by_user_id: UUID | None = None | ||
| accepted_by_user_id: UUID | None = None |
Comment on lines
+641
to
+648
| # Prevent granting roles at or above the caller's level. | ||
| requested_role = normalize_role(payload.role) | ||
| caller_rank = ROLE_RANK.get(ctx.member.role, 0) | ||
| requested_rank = ROLE_RANK.get(requested_role, 0) | ||
| if requested_rank >= caller_rank: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
| detail="Cannot grant a role equal to or above your own", |
Comment on lines
+485
to
+494
| new_role = normalize_role(updates["role"]) | ||
| # Prevent granting roles at or above the caller's level. | ||
| caller_rank = ROLE_RANK.get(ctx.member.role, 0) | ||
| new_rank = ROLE_RANK.get(new_role, 0) | ||
| if new_rank >= caller_rank: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
| detail="Cannot grant a role equal to or above your own", | ||
| ) | ||
| updates["role"] = new_role |
Comment on lines
+725
to
735
| # Require both emails to be present — reject if either is missing to | ||
| # prevent bypassing the email-binding check via None/empty values. | ||
| if not invite.invited_email or not auth.user.email: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
| detail="Invite requires a matching email address", | ||
| ) | ||
| if normalize_invited_email(invite.invited_email) != normalize_invited_email( | ||
| auth.user.email | ||
| ): | ||
| raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) |
Comment on lines
+642
to
+646
| requested_role = normalize_role(payload.role) | ||
| caller_rank = ROLE_RANK.get(ctx.member.role, 0) | ||
| requested_rank = ROLE_RANK.get(requested_role, 0) | ||
| if requested_rank >= caller_rank: | ||
| raise HTTPException( |
Comment on lines
112
to
+121
| class OrganizationInviteRead(SQLModel): | ||
| """Organization invite payload returned from read endpoints.""" | ||
|
|
||
| id: UUID | ||
| organization_id: UUID | ||
| invited_email: str | ||
| role: str | ||
| all_boards_read: bool | ||
| all_boards_write: bool | ||
| token: str | ||
| has_token: bool = False |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a critical 3-vulnerability exploit chain that allows any authenticated admin to fully take over any organization:
OrganizationInviteReadreturned the rawtokenfield in every API response (create, revoke, list). Replaced withhas_token: boolcomputed via a Pydanticmodel_validator(mode='wrap').accept_org_inviteshort-circuited the email check when eitherinvited_emailorauth.user.emailwasNone/empty, allowing anyone with the token to accept regardless of intended recipient. Now explicitly rejects when either email is missing.create_org_inviteorupdate_org_member— admins could grantownerrole. AddedROLE_RANKcomparison that blocks granting roles at or above the caller's own rank.Files Changed
backend/app/schemas/organizations.py—token: str→has_token: bool+ wrap validatorbackend/app/api/organizations.py— email validation fix + role hierarchy checks on invite create and member updateCVSS
9.8 Critical — Network / Low complexity / Low privilege / No interaction / Scope changed
Test plan
has_token: truebut no raw token valuerole: "owner"— expect 403"owner"— expect 403role: "admin"— expect success🤖 Generated with Claude Code